서론
Blog-Scrapper 서비스를 띄워두기만 한지 일년이 지났다. 현재는 단순히 블로그 제목에 포함된 키워드로만 검색이 가능했다. 예를 들어 “Kafka Streams를 대규모 프로젝트에서 적용한 사례”를 찾고 싶다고 해도, 결국 kafka streams 키워드를 입력하는 것이 최선이었다. 사용자는 ‘단어’가 아니라 ‘의도’를 질문하지만 키워드 검색은 단어의 존재 여부만 보고, 사용자의 의도가 담긴 문장은 이해하지 못한다. 검색 기능을 LLM 서비스 처럼 질의 형태로 입력할 수 있다면, 그리고 그에 맞는 블로그를 찾아 줄 수 있으면 더 나은 검색 기능을 제공할 수 있다고 생각했다. 이것이 가능하려면 우선 구조적으로 데이터 저장 방식이 바뀌어야 했는데, 현재는 단순히 기술 블로그의 title 과 url, 그리고 summary (optional) 부분만 저장하고 있었다. 즉 질의문에 맞는 응답을 주기 위해선 제목뿐만 아니라 본문 내용까지 저장하는 형태로 변경되어야 한다. 단순히 본문 내용을 긁어서 통째로 저장만 하면 될까? 이는 검색 범위를 ‘제목 → 본문 전체’ 로 넓히지만 여전히 단순 키워드 검색의 한계를 넘지 못한다. 문서는 길지만 여러 주제가 섞여 있기 때문에 사용자가 원하는 구체적인 내용을 찾는 것이 아니라 그저 본문 속에서 키워드 존재 여부만 확인하는 수준에 그친다.
자연어 쿼리를 이해하고 대응하려면, 본문 자체가 아닌 글의 ‘의미 단위’로 접근해야 했다. 블로그는 보통 일관된 주제로 글을 작성하지만 다양한 키워드가 섞여 있을 수 있는데, 문서 전체를 하나의 데이터로 저장하면 이런 맥락들이 한 벡터 안에 뒤섞여 버린다. 결국 제목, 본문 키워드 매칭 구조에서 벗어나 본문의 내용을 보다 작은 단락으로 나누고, 각 단락을 의미적으로 표현할 수 있는 구조가 필요했다. 결국 고민의 초점은 ‘어떻게 본문 내용을 저장할 것인가’로 옮겨갔다. 단순 텍스트 저장방식을 넘어 단락별로 의미를 표현할 수 있는 벡터 기반 저장 방식을 도입해 데이터 전체의 의미적 구조를 다시 정의하기로 했다.
전체 작업 플로우
- MongoDB 에 저장된 데이터(url, document_id) 를 가져온다.
- url 을 호출하여 블로그 본문 데이터를 가져온다.
- 본문 데이터를 벡터 DB에 저장하기 전 전처리 과정을 거친다.
- 정제된 데이터에 임베딩 처리를 한다.
- 임베딩 된 데이터를 벡터DB 에 저장한다.
데이터 전처리: 긁어오고(crawl), 정제하고(cleaning), 잘게 나눈다(chunk)
Crawl4AI
가장 먼저 해야 할 일은 각 포스트의 본문을 가져오는 작업이었다. 이미 MongoDB 에 2000여개의 블로그 포스트 메타정보(title, url) 가 저장돼 있기 때문에 url 을 호출하여 포스트의 본문 데이터를 가져오기로 했다. 단순 HTTP GET이 아니라 실제 HTML 에서 의미 있는 텍스트만 뽑아야 한다. 이 과정에서는 Crawl4AI 를 선택했다. 텍스트 밀도 기반으로 콘텐츠 분석이 가능한데, 간단히 말해 HTML 태그와 대비 실제 텍스트의 비율을 계산해 본문으로 판단되는 영역만 자동으로 선별해준다. 덕분에 추가적인 XPath 튜닝이나 파싱 로직 없이도, 대부분의 블로그에서 광고 영역이나 페이지 네비게이션 요소를 자연스럽게 제외하고 순수 본문 내용만 추출할 수 있었다.
from crawl4ai import AsyncWebCrawler async def extract_main_content_with_crawl4ai(url: str) -> dict: try: async with AsyncWebCrawler(verbose=False) as crawler: result = await crawler.arun(url=url) ... except Exception as e: ...
문서를 하나의 벡터로 만들지 않은 이유
본문 데이터가 정제 됐으니 이제 바로 임베딩 시키면 될까? 문서를 하나의 벡터로 만드는 방식은 “너무 큰 의미 공간”을 담는 문제가 생긴다. 가령 하나의 문서에 gRPC, Kafka, Go 등 여러 내용이 섞여 있으면 하나의 벡터는 그 모든걸 평균 내버린다. 벡터는 문서의 의미를 수학적으로 압축한 숫자 덩어리에 불과하다. 각각의 문단, 문장이 의미를 유지하려면 하나의 벡터가 아닌 작은 청크 단위로 나누어 벡터화 해야 개별 의미가 살아나고 검색도 더 정확해진다.
그래서 본문을 적당히 작은 의미 단위로 나누는 chunking 이 필요했다. 프로젝트에서는 최대 900자, 최소 250자, 문장 오버랩 1을 기준으로 청킹을 했다.
덕분에 검색을 하면 “문서 전체”가 아닌 “문서 속 가장 관련성 높은 단락”이 뜨게 된다.
텍스트 임베딩 모델
전처리 과정을 거쳤으니 이제 문서 데이터를 벡터 임베딩 해야 한다. 앞서 벡터는 문서를 수학적으로 압축한 숫자 덩어리라고 설명했는데, 조금 더 자세히 말하면 벡터란 문서의 의미를 고정된 차원의 실수 공간에 매핑한 표현이다. 즉 비슷한 의미의 텍스트끼리는 벡터 공간에서 서로 가깝게 위치하도록, 의미적으로 먼 텍스트는 멀리 떨어지도록 설계된 의미 기반 좌표계라고 할 수 있다. (학창 시절에 배운 벡터는 방향성을 가진다는 걸 생각하면 이해하기 쉽다)
이 벡터 값은 단순한 숫자 배열이 아니라 문서의 의미 단위를 기준으로 검색하기 위한 핵심 인덱스가 된다. 전통적인 DB가 키워드나 정규식을 기반으로 문자열을 비교했다면, 벡터 기반 검색은 코사인 유사도나 내적을 통해 텍스트 간의 의미적 거리를 측정한다. “grpc를 활용한 실전 사례”라고 검색했을 때 해당 단어가 정확히 일치하지 않더라도 “grpc를 사용한 마이크로서비스 개선” 같은 문단을 찾아낼 수 있다는 뜻이다. 결국 임베딩 벡터는 자연어 질의와 문서 간 의미적 연결을 담당하는 중심 구조라고 볼 수 있다.
결론부터 말하면 이번 개선 작업에서는 만족할만한 결과를 얻지 못했는데, 우선 모델 탓을 한번 해본다. OpenAI나 Gemini의 text-embedding 모델이 좋다는 소문은 많이 들었지만, 비용 문제와 로컬에서 빠르게 반복하려면 허깅페이스의 오픈소스 모델이 훨씬 테스트하기 좋기 때문에 bge-m3 모델을 사용하기로 했다.
def generate_embeddings(self, texts: Iterable[str]) -> list[list[float]]: text_list = [self._validate_text(text) for text in texts if text is not None] if not text_list: raise ValueError("At least one non-empty text chunk is required") try: embeddings = self.model.encode( text_list, convert_to_numpy=True, normalize_embeddings=self.normalize_embeddings, show_progress_bar=False, ) return [vector.tolist() for vector in embeddings] except Exception as exc: raise RuntimeError(f"Failed to generate embeddings: {exc}")
사실 처음에는 bge-small-en-v1.5라는 가벼운 모델을 사용했다. 384차원 모델로 빠르고 연산 비용이 적게 드는 장점이 있지만 한글 + 영어 혼합 문서에서는 사용하기 적합하지 않았다. 한글이 대부분인 문서라도 중간에 포함된 기술 용어(영어) 때문에 의미 표현이 균질하게 나오지 않았고, 문서가 길어지면 의미가 더 쉽게 희석되어 동일 문서 내 서로 다른 문단들이 임베딩 공간에서 모호하게 뭉치는 현상도 발생했다.
이후 BAAI/bge-m3 로 바꾸면서 이러한 문제가 거의 해결됐다. bge-m3는 1024차원 대형 모델로, 한글·영어 혼합 도메인에서도 안정적인 성능을 보이고, 긴 문서에서의 의미 보존 능력도 더 좋았다. 특히 한국어 자연어 질의의 반응성이 확실히 좋아졌고, 단락 검색 정확도가 체감될 정도로 향상됐다.
Vector DB 선택 - Milvus Lite
임베딩을 어디에 저장할지가 고민이었는데 우선 Milvus Lite로 결정했다. 분산 저장 구조가 필요한 것도 아니고 블로그 포스트 데이터도 2,000여 건 정도라 로컬에서 실행하기 쉬운 milvus 를 선택했다.
Milvus가 “가장 좋은 벡터 DB” 라고 느껴서 선택한 것은 아니다. Qdrant 도 많이 사용하는 걸로 아는데 너무 엔터프라이즈 급에서 사용되는 것 같아 나에겐 과하다는 느낌이었다. Chroma 역시 간단한 로컬 개발 환경에서는 매우 효율적으로 동작하지만 이번 프로젝트처럼 로컬에서 가볍게 시작하되, 나중에 데이터가 늘어났을 때 인덱스 전략을 세밀하게 조정할 수 있는 여지를 남겨두고 싶다는 요구에는 Milvus가 더 잘 맞았다. 사실 현재 규모라면 Chroma 로도 충분하다고 느꼈지만 공식문서를 보고 더 빠르게 적용하기에는 Milvus 가 좋았다. 작게 시작해서 크게 확장할 수 있는 설계 유연성, 그리고 Milvus Lite 를 통해 로컬 실험을 매우 빠르게 반복할 수 있다는 점이 더 컸다. 별도의 서버나 Docker 환경 없이 단순히 .db 파일 하나만으로 로컬 벡터 저장소를 구성할 수 있어 빠르게 실험하기에도 좋았다.
Milvus에 저장하는 부분은 다음과 같이 구성했다. doc_id 는 MongoDB 의 document id 로 milvus 에서 쿼리한 결과를 원천 데이터와 매핑할 수 있도록 함께 저장했다. chunk_index 는 사실 현재 구조에선 필요없는 정보지만 추후에 어떤 청크에서 쿼리한 결과인지, 혹은 청크 간의 순서를 고려할 경우가 있어 추가했다.
async def insert_embeddings(doc_id: str, chunks: List[str], vectors: List[List[float]]): entities = [ {"name": "id", "type": DataType.VARCHAR, "values": [f"{doc_id}:{i}" for i in range(len(chunks))]}, {"name": "doc_id", "type": DataType.VARCHAR, "values": [doc_id] * len(chunks)}, {"name": "chunk_index", "type": DataType.INT64, "values": list(range(len(chunks)))}, {"name": "vector", "type": DataType.FLOAT_VECTOR, "values": vectors}, ] collection.insert(entities)
검색을 해보자.
벡터 저장까지 끝났다면, 이제는 실제로 검색이 잘 되는지 확인할 차례다. 검색 과정도 기본 구조는 단순하다. 사용자가 입력한 자연어 쿼리를 다시 임베딩 모델에 넣어 벡터로 변환하고, 이 벡터를 Milvus에 던져 “가장 의미적으로 가까운 청크”를 찾아오도록 설계했다. 일반적인 검색 엔진이 키워드를 문자열 매칭으로 처리하는 것과 다르게, 여기서는 **‘문장 → 벡터 → 의미 거리 측정’**이라는 과정을 거친다.
다만 의미 기반 검색의 특성상 동일한 문서가 여러 청크로 나누어져 저장돼 있기 때문에, 검색 결과 상위에는 같은 문서의 여러 단락이 한꺼번에 등장할 수 있다. 사용자는 문서를 단락 단위가 아닌 “문서 단위”로 확인하길 원하므로, 검색 이후에 중복 문서 제거(deduplication) 로직을 넣었다. 이를 위해 각 검색 후보를 순회하며 이미 본 doc_id는 스킵하고, 처음 등장한 문서만 결과로 남긴다.
검색 과정은 크게 세 가지로 나뉜다.
- 쿼리를 벡터화한다.
사용자가 자연어로 입력한 문장을 임베딩 모델에 넣어 의미 기반 좌표로 변환한다.
예: “gRPC streams 사용 사례”
- Milvus에서 의미적으로 가까운 벡터를 검색한다.
코사인 유사도 기반으로 가장 가까운 chunk들을 가져오는데, 이때 deduplication을 위해 실제 limit보다 넉넉하게 후보를 가져온다.
- 문서 메타데이터를 MongoDB에서 다시 조합한다.
검색 결과는 chunk 단위이지만, 사용자에게 보여줘야 하는 건 문서 단위다. 그래서 최종적으로 doc_id 기반으로 MongoDB에서 원본 정보를 다시 가져와 하나의 응답으로 구성한다.
async def search_posts(request: SearchRequest): try: # Step 1: Generate query embedding embedding_service = get_embedding_service() query_vector = embedding_service.generate_query_embedding(request.query) # Step 2: Search in Milvus (fetch extra hits to account for deduplication) milvus_client = get_milvus_client() candidate_limit = max(request.limit * 5, request.limit) raw_results = milvus_client.search(query_vector, limit=candidate_limit) # Deduplicate results by document ID deduped_results = [] seen_docs: set[str] = set() for result in raw_results: doc_id = result["document_id"] if doc_id in seen_docs: continue seen_docs.add(doc_id) deduped_results.append(result) if len(deduped_results) >= request.limit: break if not deduped_results: return { "success": True, "query": request.query, "results": [], "elapsed_time_seconds": round(time.time() - start_time, 2), } ...
실패 원인 분석 - 생각한대로 결과가 안나옴
블로그의 본문을 임베딩하여 Milvus에 저장했으니 질의 형태로 검색하면 당연히 그에 대응하는 문서가 나올 것이라 기대했다. 기대에 부풀어 테스트 했지만 예상 결과와는 거리가 멀었다.
grpc streams 사용 사례라는 질의를 던졌을 때 가장 첫 번째 결과가 뜬금없이 타입스크립트 성능에 관한 포스트, 두 번째 결과는 Gateway 관련 글로 해당 포스트에도 gRPC의 관련 내용은 없었다.
![]()
1. 전처리의 불완전함
첫번째로 의심되는 실패 원인은 전처리의 불완전함이었다. crawl4ai 를 사용해 코드 블록이나 사이드바 텍스트를 충분히 제거했다고 생각했지만, 실제로 검증과정을 거치지 않았다. 샘플 데이터라도 로그를 남겨 확인했어야 했는데 crawl4ai 가 알아서 잘 해주겠지 라는 생각에 이 부분을 간과했다. 물론 알아서 전처리를 잘 했을 수도 있지만 실제로 확인해보는 과정은 필요하다고 본다. 임베딩 모델이 의도치 않은 단어들의 영향을 받아 엉뚱한 의미 공간으로 벡터를 밀어 넣었기 때문에 결과가 틀어진 것이라는 첫번째 의심이 들었다.
2. chunkin 전략 문제
이번 프로젝트에서는 최대 900자 기준으로 청킹을 했는데, 한 청크 안에 너무 많은 주제가 섞여 있거나 문맥 연결이 부자연스럽다면, 검색할 때 쿼리가 특정 단락과 연결되지 않고 주변 문맥에 끌려가는 일이 발생한다. 청크가 너무 길면 의미는 희석되고, 너무 짧으면 의미가 불안정해지기 때문에 길이 기준이 적절했는지도 다시 점검해볼 필요가 있었다. 다음과 같은 방식으로 청킹 전략의 적정선을 파악할 수 있다.
- 가장 단순한 방법: 다양한 청크 길이로 AB 테스트
가장 직접적인 방법은 청크 길이를 바꿔가며 검색 품질을 비교하는 것이다.
- 300자 / 600자 / 900자 / 1200자
- 오버랩 0 / 1 / 2
- 문장 단위 기반 vs 글자 단위 기반
이렇게 여러 조합으로 저장 스키마를 만들어 검색해보고, 특정 쿼리에 대해 상위 검색 결과가 얼마나 "일관적으로 관련 있는지" 비교할 수 있다. 이 과정에서 보통 600~900자 수준에서 품질 최적점이 형성되는 경향이 있다.
- anchor query 집합 만들기
청킹 전략을 검증하려면 **고정된 쿼리 세트(anchor queries)**를 준비하는 게 좋다.
예를 들어:
- “gRPC 스트림 활용법”
- “Redis 여러 DB를 어떻게 쓰는지”
- “Kafka consumer lag 문제 해결”
- “Go에서 defer가 동작하는 방식”
이런 쿼리에 대해 *‘정답이라고 생각하는 문서/문단’*을 수동으로 1~2개 정해둔다. 이를 정량적 기준으로 활용하면, 청킹 전략 변경이 검색 품질에 어떤 영향을 주는지 명확히 비교할 수 있다. 이걸 흔히 “Gold set”, 또는 “human-verified evaluation set”이라고 부른다.
- 청크 간 semantic overlap 측정
청크가 너무 짧으면 비슷한 의미의 청크가 여러 개 생성되면서 검색 결과 상위가 “비슷한 문장 여러 개”로 무의미하게 채워질 수 있다. 이를 방지하기 위해 semantic overlap 분석을 할 수 있다.
방법은 간단하다:
- 동일 문서 내 모든 청크 쌍(pair)의 cosine similarity를 계산
- 평균·표준편차를 비교
- 특정 threshold 이상이면 “과도하게 중복된 청크”로 판단
이 작업을 통해 “지금 청킹 전략이 의미를 잘 분해했는지, 아니면 비슷한 단락을 여러 개 만들어버렸는지” 확인할 수 있다.
3. 임베딩 모델의 표현력 한계
이번에 사용한 BAAI/bge-m3는 좋은 모델이지만, 여전히 오픈소스 모델이며, gRPC처럼 상대적으로 niche한 기술 키워드를 완벽하게 이해한다고 기대하긴 어렵다. 특히 “streams”, “gateway”, “performance” 같은 단어들은 기술 문서에서 여러 문맥에서 자주 등장하기 때문에, 모델이 이를 헷갈려 다른 방향으로 벡터를 배치했을 가능성이 있다. 비용을 좀 더 들이더라도 openai 나 gemini 모델을 사용해봐야겠다는 생각도 들었다.
그리고 이렇게 영 뜬금없는 질의를 던져도 응답은 뭐라도 나온다. 프로미스나인 입덕 멤버는 프다클에 있나보다. 역시 토스
![]()
개선해볼 만한 사항
이번 프로젝트는 기본적인 파이프라인(스크래핑 → 전처리 → 청킹 → 임베딩 → 벡터 저장 → 검색)까지는 잘 구축했지만, 성능이나 검색 정확도 측면에서는 여전히 개선 여지가 많다. 우선 빠르게 개발하기 위해 Python 기반으로 구현했는데, crawl4ai도 Golang SDK를 제공하고, Milvus 역시 공식 문서에서 Go 예제 코드를 적극적으로 안내하고 있어서 전체 파이프라인을 Go로 포팅해보는 것도 고려 중이다.
파이썬이 느린 언어라고 생각하지는 않지만, 임베딩 처리 시 CPU가 빠르게 치솟는 것을 보면 로컬 환경에서는 언어 선택에 따른 체감 차이가 있을 수도 있다. 특히 청킹 + 임베딩 부분은 연산량이 많기 때문에, Go 특유의 메모리 관리 방식이나 고루틴 기반 병렬 처리의 이점이 실제로 어떤 차이를 만드는지도 실험해볼 가치가 있다. 단순 이식이 아니라, “Python vs Go 환경에서 벡터 파이프라인의 체감 성능이 어떤지” 비교해보는 것도 재미있는 주제가 될 듯하다.
검색 결과 반환 방식도 손볼 필요가 있다.
지금은 단순히 limit=5 기준으로 상위 N개를 반환하고 있지만, 어떤 기준으로 정렬할지 명확히 정의하지 않았다. 코사인 유사도 점수로 정렬할지, distance를 기준으로 할지, 혹은 document-level score를 따로 계산할지도 정해야 한다. 현재는 chunk-level 유사도만 기준으로 하고 있기 때문에, 문서 단위 랭킹을 계산하는 로직이 더 들어가야 정확도가 높아진다.
또 한 가지 중요한 개선 포인트는 post-processing, 특히 reranking(재정렬) 단계다.
지금은 단순히 deduplication만 수행하고 Milvus가 반환한 순서를 그대로 사용하는데, 의미 기반 검색에서는 상위 K개의 후보를 가져온 뒤 별도의 언어모델이나 lightweight scoring 모델을 적용해 재정렬하는 방식이 일반적이다. 예를 들어 쿼리와 문서 청크를 다시 한 번 sentence-transformer로 scoring한다든지, 쿼리-청크 쌍을 LLM으로 판단하게 하는 방식도 있다. 이 과정을 생략한 상태라 Milvus가 첫 번째로 반환한 후보가 실제 의도와 거리가 먼 경우가 종종 발생한다.
결국 개선 포인트는 분명하다.
언어 레벨에서의 성능 개선, 검색 스코어링 전략, reranking 기반 후처리 등 여러 단계에서 실험해볼 여지가 많다. 의미 기반 검색은 한 번 구축했다고 끝나는 구조가 아니기 때문에, 앞으로는 각 단계마다 작은 실험을 빠르게 반복하며 최적점을 찾아가는 방향으로 발전시켜보고자 한다.